package com.tyron.viewbinding.tool.writer

import com.tyron.viewbinding.tool.ext.L
import com.tyron.viewbinding.tool.ext.N
import com.tyron.viewbinding.tool.ext.S
import com.tyron.viewbinding.tool.ext.T
import com.tyron.viewbinding.tool.ext.W
import com.tyron.viewbinding.tool.ext.classSpec
import com.tyron.viewbinding.tool.ext.constructorSpec
import com.tyron.viewbinding.tool.ext.fieldSpec
import com.tyron.viewbinding.tool.ext.javaFile
import com.tyron.viewbinding.tool.ext.methodSpec
import com.tyron.viewbinding.tool.ext.parameterSpec
import com.tyron.viewbinding.tool.store.GenClassInfoLog
import com.tyron.viewbinding.tool.writer.ViewBinder.RootNode
import com.squareup.javapoet.ClassName
import com.squareup.javapoet.CodeBlock
import com.squareup.javapoet.NameAllocator
import com.squareup.javapoet.ParameterSpec
import com.squareup.javapoet.TypeName.BOOLEAN
import org.openjdk.javax.lang.model.element.Modifier.FINAL
import org.openjdk.javax.lang.model.element.Modifier.PRIVATE
import org.openjdk.javax.lang.model.element.Modifier.PUBLIC
import org.openjdk.javax.lang.model.element.Modifier.STATIC

fun ViewBinder.toJavaFile(useLegacyAnnotations: Boolean = false) =
    JavaFileGenerator(this, useLegacyAnnotations).create()

fun ViewBinder.generatedClassInfo() = GenClassInfoLog.GenClass(
    qName = generatedTypeName.toString(),
    modulePackage = generatedTypeName.packageName(),
    variables = emptyMap(),
    implementations = emptySet()
)

private class JavaFileGenerator(
    private val binder: ViewBinder,
    private val useLegacyAnnotations: Boolean
)
{
    private val annotationPackage =
        if (useLegacyAnnotations) "android.support.annotation" else "androidx.annotation"
    val viewBindingPackage = (if (useLegacyAnnotations) "android" else "androidx") + ".viewbinding"
    private val nonNull = ClassName.get(annotationPackage, "NonNull")
    private val nullable = ClassName.get(annotationPackage, "Nullable")

    private val fieldNames = NameAllocator().apply {
        // Since the binding names are used in public fields, allocate those first.
        binder.bindings.forEach { binding ->
            newName(binding.name, binding)
        }
    }
    private val rootFieldName = fieldNames.newName("rootView")

    fun create() = javaFile(binder.generatedTypeName.packageName(), typeSpec()) {
        addFileComment("Generated by view binder compiler. Do not edit!")
    }

    private fun typeSpec() = classSpec(binder.generatedTypeName) {
        addModifiers(PUBLIC, FINAL)

        addSuperinterface(ClassName.get(viewBindingPackage, "ViewBinding"))

        // TODO elide the separate root field if the root tag has an ID (and isn't a binder)
        addField(rootViewField())
        addFields(bindingFields())

        addMethod(constructor())
        addMethod(rootViewGetter())

        if (binder.rootNode is RootNode.Merge) {
            addMethod(mergeInflate())
        } else {
            addMethod(oneParamInflate())
            addMethod(threeParamInflate())
        }

        addMethod(bind())
    }

    private fun rootViewField() = fieldSpec(rootFieldName, binder.rootNode.type) {
        addModifiers(PRIVATE, FINAL)
        addAnnotation(nonNull)
    }

    private fun bindingFields() = binder.bindings.map { binding ->
        fieldSpec(binding.name, binding.type) {
            addModifiers(PUBLIC, FINAL)

            // TODO addJavadoc when types were normalized to View due to different declarations.

            if (binding.isRequired) {
                addAnnotation(nonNull)
            } else {
                addJavadoc(
                    renderConfigurationJavadoc(
                        binding.presentConfigurations,
                        binding.absentConfigurations
                    )
                )
                addAnnotation(nullable)
            }
        }
    }

    private fun constructor() = constructorSpec {
        addModifiers(PRIVATE)

        addParameter(parameterSpec(binder.rootNode.type, rootFieldName) {
            addAnnotation(nonNull)
        })
        addStatement("this.$rootFieldName = $rootFieldName")

        binder.bindings.forEach { binding ->
            val name = fieldNames.get(binding)
            addParameter(parameterSpec(binding.type, name) {
                addAnnotation(if (binding.isRequired) nonNull else nullable)
            })
            addStatement("this.$1N = $1N", name)
        }
    }

    private fun rootViewGetter() = methodSpec("getRoot") {
        // TODO addJavadoc about this being the parent if the root tag was <merge> ...right?

        addAnnotation(Override::class.java)
        addAnnotation(nonNull)
        addModifiers(PUBLIC)

        returns(binder.rootNode.type)
        addStatement("return $rootFieldName")
    }

    private fun oneParamInflate() = methodSpec("inflate") {
        // TODO addJavadoc

        addModifiers(PUBLIC, STATIC)
        addAnnotation(nonNull)
        returns(binder.generatedTypeName)

        val inflaterParam = parameterSpec(ANDROID_LAYOUT_INFLATER, "inflater") {
            addAnnotation(nonNull)
        }
        addParameter(inflaterParam)

        addStatement("return inflate($N, null, false)", inflaterParam)
    }

    private fun threeParamInflate() = methodSpec("inflate") {
        // TODO addJavadoc

        addModifiers(PUBLIC, STATIC)
        addAnnotation(nonNull)
        returns(binder.generatedTypeName)

        val inflaterParam = parameterSpec(ANDROID_LAYOUT_INFLATER, "inflater") {
            addAnnotation(nonNull)
        }
        val parentParam = parameterSpec(ANDROID_VIEW_GROUP, "parent") {
            addAnnotation(nullable)
        }
        val attachToParentParam = parameterSpec(BOOLEAN, "attachToParent")

        addParameter(inflaterParam)
        addParameter(parentParam)
        addParameter(attachToParentParam)

        addStatement("$T root = $N.inflate($L, $N, false)",
            ANDROID_VIEW, inflaterParam, binder.layoutReference.asCode(), parentParam)
        beginControlFlow("if ($N)", attachToParentParam)
        addStatement("$N.addView(root)", parentParam)
        endControlFlow()
        addStatement("return bind(root)")
    }

    private fun mergeInflate() = methodSpec("inflate") {
        // TODO addJavadoc

        addModifiers(PUBLIC, STATIC)
        addAnnotation(nonNull)
        returns(binder.generatedTypeName)

        val inflaterParam = parameterSpec(ANDROID_LAYOUT_INFLATER, "inflater") {
            addAnnotation(nonNull)
        }
        val parentParam = parameterSpec(ANDROID_VIEW_GROUP, "parent") {
            addAnnotation(nonNull)
        }

        addParameter(inflaterParam)
        addParameter(parentParam)

        beginControlFlow("if ($N == null)", parentParam)
        addStatement("throw new $T($S)", NullPointerException::class.java, parentParam.name)
        endControlFlow()

        addStatement("$N.inflate($L, $N)",
            inflaterParam, binder.layoutReference.asCode(), parentParam)
        addStatement("return bind($N)", parentParam)
    }

    private fun bind() = methodSpec("bind") {
        // TODO addJavadoc
        val viewBindings = ClassName.get(viewBindingPackage, "ViewBindings")
        addModifiers(PUBLIC, STATIC)
        addAnnotation(nonNull)
        returns(binder.generatedTypeName)

        // We use a dedicated name allocator here because we want the public parameter name to take
        // precedence over any view with a matching ID which is only used as a local.
        val localNames = NameAllocator()

        val rootParam = parameterSpec(ANDROID_VIEW, localNames.newName("rootView")) {
            addAnnotation(nonNull)
        }
        addParameter(rootParam)

        val rootBinding = (binder.rootNode as? RootNode.Binding)?.binding
        val nonRootBindings = binder.bindings.filter { it !== rootBinding }

        if (nonRootBindings.isEmpty()) {
            // Without any bindings that invoke findViewById, an erroneously-null rootView can be
            // propagated into the binding instance and its non-null getRoot() method. Synthesize
            // an explicit null check to prevent this.
            beginControlFlow("if ($N == null)", rootParam)
            addStatement("throw new $T($S)", NullPointerException::class.java, rootParam.name)
            endControlFlow()
            addCode("\n")
        }

        /** Non-null when error-handling is being generated. */
        val id: String?

        val hasRequiredBindings = nonRootBindings.any { it.isRequired }
        if (hasRequiredBindings) {
            addComment("The body of this method is generated in a way you would not otherwise write.")
            addComment("This is done to optimize the compiled bytecode for size and performance.")

            id = localNames.newName("id")
            addStatement("int $id")

            // By using a named block and break statements, the generated code compiles to bytecode
            // which optimizes for the common case of all required views being present. It also allows
            // de-duplicating the exception handling code to save bytecode size.
            beginControlFlow("missingId:")
        } else {
            id = null
        }

        val constructorParams = mutableListOf<CodeBlock>()
        constructorParams += rootParam.asViewReference(binder.rootNode.type)

        binder.bindings.forEach { binding ->
            val viewName = localNames.newName(binding.name)

            val viewType = when (binding.form) {
                ViewBinding.Form.View -> binding.type
                ViewBinding.Form.Binder -> ANDROID_VIEW
            }
            val viewInitializer = if (binding === rootBinding) {
                // If this corresponds to the root binding, we can re-use the input View argument.
                rootParam.asViewReference(viewType)
            } else if (hasRequiredBindings) {
                // Place the id value first into the local in case it's needed for error handling.
                // We do this unconditionally (even for optional bindings) so that the Dalvik
                // bytecode re-uses the same register rather than using one for optional IDs and
                // one for required IDs.
                addStatement("$id = $L", binding.id.asCode())
                CodeBlock.of("$T.findChildViewById($N, $id)", viewBindings, rootParam)
            } else {
                CodeBlock.of("$T.findChildViewById($N, $L)", viewBindings, rootParam,
                    binding.id.asCode())
            }
            addStatement("$T $viewName = $L", viewType, viewInitializer)

            if (binding.isRequired && binding !== rootBinding) {
                beginControlFlow("if ($viewName == null)")
                addStatement("break missingId")
                endControlFlow()
            }

            val constructorParam = when (binding.form) {
                ViewBinding.Form.View -> viewName
                ViewBinding.Form.Binder -> {
                    val binderName = localNames.newName("binding_${binding.name}")
                    if (binding.isRequired) {
                        addStatement("$1T $binderName = $1T.bind($viewName)", binding.type)
                    } else {
                        addStatement("""
                            $1T $binderName = $viewName != null
                            ? $1T.bind($viewName)
                            : null
                        """.trimIndent(), binding.type)
                    }
                    binderName
                }
            }
            constructorParams += CodeBlock.of(L, constructorParam)

            addCode("\n")
        }

        addStatement("return new $T($L)", binder.generatedTypeName,
            CodeBlock.join(constructorParams, ",$W"))

        if (id != null) {
            endControlFlow()

            val missingId = localNames.newName("missingId")
            addStatement(
                "$T $missingId = $N.getResources().getResourceName($id)",
                String::class.java,
                rootParam
            )

            // String.concat(String) produces less bytecode than '+' (StringBuilder).
            addStatement(
                "throw new $T($S.concat($missingId))",
                NullPointerException::class.java,
                "Missing required view with ID: "
            )
        }
    }

    /** Return a [CodeBlock] reference to [this] as [viewType], emitting a cast if needed. */
    private fun ParameterSpec.asViewReference(viewType: ClassName): CodeBlock {
        return if (viewType != ANDROID_VIEW) {
            CodeBlock.of("($T) $N", viewType, this)
        } else {
            CodeBlock.of(N, this)
        }
    }

    /** Return the storage type for the view backing a [RootNode]. */
    private val RootNode.type get() = when (this) {
        is RootNode.Merge -> ANDROID_VIEW
        is RootNode.View -> type
        is RootNode.Binding -> binding.type
    }
}
